iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Software Development

Kotlin Lambda 從零開始系列 第 26

Kotlin Lambda 從零開始 Day 26:union / intersect / subtract / plus / minus — 集合運算

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會用 TDD 手刻 myUnionmyIntersectmySubtract,搞懂集合的聯集、交集、差集底層怎麼跑。再來看 +- 運算子怎麼操作集合,順便幫 Collection 篇做個總收尾

Kotlin ↔ C# 對照表

Kotlin C# LINQ 備註
union(other) Union(other) Kotlin 回傳 Set;C# 回傳延遲的 IEnumerable
intersect(other) Intersect(other) 同上,兩邊都會去除重複
subtract(other) Except(other) C# 叫 Except,名字差很多但功能一樣
list + element Append(element) Kotlin 回傳新 List,不會去除重複
list - element 無直接對應 Kotlin 回傳新 List
list + otherList Concat

要注意回傳型別這一欄的差異:Kotlin 的三個集合運算會立刻算出一個 Set,C# 的 Union / Intersect / Except 則是回傳延遲執行的 IEnumerable<T>,語意上會去除重複但不是 Set 型別。Kotlin 的 +- 運算子是語法糖,底層呼叫 plusminus 函式

TDD 實作 myUnion

Red:先寫測試

@Test
fun `union two integer sets`() {
    val a = listOf(1, 2, 3)
    val b = listOf(3, 4, 5)
    val result = a.myUnion(b)
    assertEquals(setOf(1, 2, 3, 4, 5), result)
}

@Test
fun `union preserves order`() {
    val a = listOf(3, 1)
    val b = listOf(4, 1, 2)
    val result = a.myUnion(b).toList()
    assertEquals(listOf(3, 1, 4, 2), result)
}

@Test
fun `union removes duplicates within same list`() {
    val a = listOf(1, 1, 2)
    val b = listOf(2, 3, 3)
    assertEquals(setOf(1, 2, 3), a.myUnion(b))
}

@Test
fun `union handles null elements`() {
    val a = listOf(1, null)
    val b = listOf(null, 2)
    assertEquals(setOf(1, null, 2), a.myUnion(b))
}

聯集把兩邊的元素合在一起,重複的只留一份。第二個測試驗順序,先出現的優先,3 在 a 裡先出現,所以 3 排第一。最後一個測試確認 null 不會出問題,LinkedHashSet 允許一個 null 元素,去除重複的邏輯一樣套用

Green:最小實作

infix fun <T> Iterable<T>.myUnion(other: Iterable<T>): Set<T> {
    val result = LinkedHashSet<T>()
    for (element in this) {
        result.add(element)
    }
    for (element in other) {
        result.add(element)
    }
    return result
}

LinkedHashSet 而不是 HashSet,因為聯集需要保留插入順序。先把 this 的元素加進去,再加 other 的。LinkedHashSet 自動去除重複,已經存在的元素不會被加第二次,順序也不會改變

跟 day 25 手刻的 myDistinct 不同,這裡用 LinkedHashSet 是因為它同時負責去除重複和維護順序。myDistinct 的做法是用 HashSet 追蹤已見過的元素,另外用 ArrayList 維護順序。兩種做法的效果一樣,但 union 的寫法更簡潔(順帶一提,stdlib 的 distinct() 走的是 toMutableSet().toList()toMutableSet() 回傳的正是 LinkedHashSet,跟這裡同一招)

加了 infix 修飾詞,所以可以寫成 a myUnion b。stdlib 的 union 也是 infix 函式

Refactor:往 stdlib 的寫法靠近

兩個迴圈可以縮成 toMutableSet()addAll(other),行為一模一樣只是更精簡,stdlib 就是這樣寫的。細節留到後面的「與 stdlib 原始碼比較」一起看

TDD 實作 myIntersect

Red:先寫測試

@Test
fun `intersect two integer sets`() {
    val a = listOf(1, 2, 3, 4)
    val b = listOf(3, 4, 5, 6)
    val result = a.myIntersect(b)
    assertEquals(setOf(3, 4), result)
}

@Test
fun `intersect no overlap`() {
    val a = listOf(1, 2)
    val b = listOf(3, 4)
    assertEquals(emptySet<Int>(), a.myIntersect(b))
}

@Test
fun `intersect preserves order from first`() {
    val a = listOf(4, 3, 2, 1)
    val b = listOf(1, 3)
    val result = a.myIntersect(b).toList()
    assertEquals(listOf(3, 1), result)
}

交集只保留兩邊都有的元素。順序跟第一個集合走,所以 [4, 3, 2, 1][1, 3] 的交集是 [3, 1],不是 [1, 3]

Green:最小實作

infix fun <T> Iterable<T>.myIntersect(other: Iterable<T>): Set<T> {
    val otherSet = other.toHashSet()
    val result = LinkedHashSet<T>()
    for (element in this) {
        if (otherSet.contains(element)) {
            result.add(element)
        }
    }
    return result
}

先把 other 轉成 HashSet,查詢效率 O(1)。然後走 this 的每個元素,如果在 otherSet 裡面就加進結果

為什麼不直接 for (element in other) 來查詢?因為如果 other 是 List,contains 是 O(n),整個操作就變成 O(n²)。轉成 HashSet 只需要一次 O(n),之後每次查詢 O(1),總共 O(n + m)

Refactor:往 stdlib 的寫法靠近

這個實作已經是 stdlib 的形狀,差別只在 stdlib 用 retainAll 取代手動迴圈,後面的「與 stdlib 原始碼比較」會對照原始碼

TDD 實作 mySubtract

Red:先寫測試

@Test
fun `subtract integers`() {
    val a = listOf(1, 2, 3, 4, 5)
    val b = listOf(3, 4)
    val result = a.mySubtract(b)
    assertEquals(setOf(1, 2, 5), result)
}

@Test
fun `subtract all`() {
    val a = listOf(1, 2, 3)
    val b = listOf(1, 2, 3, 4)
    assertEquals(emptySet<Int>(), a.mySubtract(b))
}

@Test
fun `subtract preserves order`() {
    val a = listOf(5, 3, 1, 4, 2)
    val b = listOf(3, 4)
    val result = a.mySubtract(b).toList()
    assertEquals(listOf(5, 1, 2), result)
}

差集是「我有但你沒有」的元素。[1, 2, 3, 4, 5] 扣掉 [3, 4] 剩下 [1, 2, 5]

要注意差集有方向性,誰扣誰結果完全不一樣。第二個測試就是在講這件事:a[1, 2, 3]b[1, 2, 3, 4]a 的元素全都在 b 裡面,所以 a.mySubtract(b) 是空集合;反過來寫 b.mySubtract(a) 得到的卻是 [4]。聯集和交集左右對調,內容還是一樣(只有順序可能不同),差集是這三個運算裡唯一不對稱的

Green:最小實作

infix fun <T> Iterable<T>.mySubtract(other: Iterable<T>): Set<T> {
    val otherSet = other.toHashSet()
    val result = LinkedHashSet<T>()
    for (element in this) {
        if (!otherSet.contains(element)) {
            result.add(element)
        }
    }
    return result
}

intersect 幾乎一樣,差別只在判斷條件反過來:intersectcontains 才加,subtract!contains 才加

Refactor:往 stdlib 的寫法靠近

intersect 同一個道理,stdlib 用 removeAll 一行取代手動迴圈,邏輯本身沒有更多可改的地方,後面的原始碼比較段落會放在一起看

TDD 實作 myPlus 與 myMinus

union / intersect / subtract 是集合運算,回傳 Set<T>plusminus 是不同的東西,它們定義在 Collection / Iterable 上、回傳 List,不會去除重複。要注意 stdlib 另有定義在 Set<T> 上的 plus overload,那個版本回傳 Set 而且會去除重複;這裡手刻的是 List 版本

Red:先寫測試

myPlus 有兩個 overload,單一元素和集合各一個,測試要分開顧。重點放在「不改動原集合」跟「重複的元素照留」

@Test
fun `plus appends element without mutating original`() {
    val original = listOf(1, 2, 3)
    val result = original.myPlus(4)
    assertEquals(listOf(1, 2, 3, 4), result)
    assertEquals(listOf(1, 2, 3), original)  // 原集合沒被動到
}

@Test
fun `plus keeps duplicates`() {
    assertEquals(listOf(1, 2, 2), listOf(1, 2).myPlus(2))
}

@Test
fun `plus collection appends every element`() {
    val original = listOf(1, 2)
    val result = original.myPlus(listOf(2, 3))
    assertEquals(listOf(1, 2, 2, 3), result)
    assertEquals(listOf(1, 2), original)  // 原集合一樣沒被動到
}

@Test
fun `plus accepts an Iterable that is not a Collection`() {
    val result = listOf(1, 2).myPlus(sequenceOf(3, 4).asIterable())
    assertEquals(listOf(1, 2, 3, 4), result)
}

第三個測試驗集合版:[1, 2] 加上 [2, 3] 得到 [1, 2, 2, 3],兩個 2 都在,這跟 union 是完全不同的行為

最後一個測試特別挑了一個不是 CollectionIterable,也就是 Sequence.asIterable() 的回傳值。現在看起來多餘,但後面 Refactor 會依 is Collection 把實作拆成兩條路,這個測試涵蓋的正是拿不到 size 的那條

myMinus 的測試重點在兩個 overload 的行為差異:單一元素只移除第一個符合者,集合版本移除所有符合者

@Test
fun `minus single element removes only the first match`() {
    val list = listOf(1, 2, 3, 2, 1)
    assertEquals(listOf(1, 3, 2, 1), list.myMinus(2))
}

@Test
fun `minus collection removes every match`() {
    val list = listOf(1, 2, 3, 2, 1)
    assertEquals(listOf(1, 3, 1), list.myMinus(listOf(2)))
}

@Test
fun `minus does not mutate original`() {
    val original = listOf(1, 2, 3)
    original.myMinus(2)
    assertEquals(listOf(1, 2, 3), original)
}

Green:最小實作

先做 myPlus

fun <T> Collection<T>.myPlus(element: T): List<T> {
    val result = ArrayList<T>(size + 1)
    result.addAll(this)
    result.add(element)
    return result
}

fun <T> Collection<T>.myPlus(elements: Iterable<T>): List<T> {
    val result = ArrayList<T>()
    result.addAll(this)
    for (element in elements) {
        result.add(element)
    }
    return result
}

plus 建立一份新的 List,把原本的元素複製過去,再加上新元素。原本的集合不會被改動

加入單一元素的版本預先分配 size + 1 的容量,避免 ArrayList 擴容

在 Kotlin stdlib 裡,list + element 會被編譯器轉換成 list.plus(element) 的呼叫。day 09 初探 operator 時提過,+ / - 這些運算子背後其實是對應名稱的函式,這裡的 plus / minus 就是那套機制套在集合上的實際應用。day 33 會把 operator overloading 完整講一遍

再做 myMinus

fun <T> Iterable<T>.myMinus(element: T): List<T> {
    val result = ArrayList<T>()
    var removed = false
    for (item in this) {
        if (!removed && item == element) {
            removed = true
        } else {
            result.add(item)
        }
    }
    return result
}

fun <T> Iterable<T>.myMinus(elements: Iterable<T>): List<T> {
    val removeSet = elements.toHashSet()
    val result = ArrayList<T>()
    for (item in this) {
        if (!removeSet.contains(item)) {
            result.add(item)
        }
    }
    return result
}

兩個 minus 版本行為不一樣,這是個容易踩到的坑

減去單一元素時,只移除第一個符合的。[1, 2, 3, 2, 1].myMinus(2) 得到 [1, 3, 2, 1],第二個 2 還在

減去一個集合時,移除所有符合的。[1, 2, 3, 2, 1].myMinus(listOf(2)) 得到 [1, 3, 1],兩個 2 都被拿掉了

為什麼?因為減去集合的版本用 HashSet 存要移除的元素,每次比對都會命中。單一元素版本用 removed flag 控制只移除一次

val list = listOf(1, 2, 3, 2, 1)
list.myMinus(2)          // [1, 3, 2, 1] — 只移除第一個 2
list.myMinus(listOf(2))  // [1, 3, 1]    — 移除所有 2

Refactor:往 stdlib 的寫法靠近

這兩組函式沒有 Lambda 參數,也就沒有 inline 的必要,結構上已經跟 stdlib 一致。唯一可以再靠近一點的是集合版 myPlus:stdlib 會先判斷 elements 是不是 Collection,是的話就用 ArrayList(size + elements.size) 預先分配容量,不是才退回沒有精確容量的 ArrayList(this)addAll

fun <T> Collection<T>.myPlus(elements: Iterable<T>): List<T> {
    if (elements is Collection) {
        val result = ArrayList<T>(this.size + elements.size)
        result.addAll(this)
        result.addAll(elements)
        return result
    }
    val result = ArrayList<T>(this)
    result.addAll(elements)
    return result
}

剛才那個傳 Sequence.asIterable() 的測試到這裡就派上用場了,改完之後兩條路都要是綠的,不然只有走 is Collection 的情況被驗到

這是整個 Collection 篇最後一次 Refactor。is Collection 在這裡的用途跟 day 22 myTake 那次不一樣:myTake 拿 size 是為了判斷「要的比有的還多,直接 toList() 走人」,這裡拿 size 是為了把容量一次開好。共通點是兩邊都靠 is Collection 換到「事先知道有幾個元素」這個資訊

與 stdlib 原始碼比較

原始碼位置:kotlin.collections_Collections.kt

stdlib 的三個集合運算都是 infix 函式,底層做法跟我們幾乎一樣

public infix fun <T> Iterable<T>.union(other: Iterable<T>): Set<T> {
    val set = this.toMutableSet()  // toMutableSet() 回傳 LinkedHashSet,已含 this 的元素
    set.addAll(other)
    return set
}

public infix fun <T> Iterable<T>.intersect(other: Iterable<T>): Set<T> {
    val set = this.toMutableSet()
    set.retainAll(other)
    return set
}

public infix fun <T> Iterable<T>.subtract(other: Iterable<T>): Set<T> {
    val set = this.toMutableSet()
    set.removeAll(other)
    return set
}

stdlib 的 union 先用 toMutableSet()this 收進一個 LinkedHashSet,再 addAll(other),跟我們手動跑兩個迴圈的效果一樣,只是更精簡。intersectretainAll 而不是手動迴圈。toMutableSet() 回傳 LinkedHashSet,保留插入順序。retainAll 移除不在 other 裡面的元素,效果跟我們的做法一樣

stdlib 的 plusminus 定義在 _Collections.kt 裡,有多個 overload(單一元素、Array、Iterable、Sequence)。我們只實作了最常用的兩個

第七部分回顧

集合運算篇兩篇到這裡結束。day 25 靠 HashSet 去除重複,做出 distinct / distinctBy;day 26 把 HashSet 的查詢特性推到聯集、交集、差集,再帶出 plus / minus 這組「不會去除重複、回傳 List」的運算子。同樣是處理重複與成員關係,回傳 Set 還是 List、會不會去除重複,是這部分最需要分清楚的地方

而這也是整個 Collection 篇的收尾。從 day 05 的 filter 走到這裡手刻了超過 50 個函式,可以發現幾乎所有函式都遵循類似的模式

inline fun <T> Iterable<T>.myXxx(predicate: (T) -> Boolean): List<T> {
    val result = ArrayList<T>()    // 建立結果容器
    for (element in this) {        // 走過每個元素
        // 某種判斷邏輯
        result.add(element)        // 符合就加進去
    }
    return result                  // 回傳新 List
}

inline + for 迴圈 + ArrayList 累積 + 回傳新 List。這個模式好讀、好寫,大多數場景效能也夠

但這個模式有個共同的代價:每個函式都會立刻分配一個新的容器,單獨呼叫沒問題,串成一條鏈就未必了。這也是下一篇進入 Sequence 的重點

本部分 API 速查

函式 篇號 用途
myDistinct day 25 去掉重複元素,保留首次出現的順序
myDistinctBy day 25 依 selector 算出的鍵去除重複
myUnion day 26 聯集,兩邊元素合一份回傳 Set
myIntersect day 26 交集,只留兩邊都有的元素
mySubtract day 26 差集,留下「我有你沒有」的元素
myPlus day 26 加入元素或集合,回傳新 List,不會去除重複
myMinus day 26 移除元素或集合,回傳新 List

小結

集合運算的底層還是 HashSet。unionLinkedHashSet 收集兩邊的元素,intersectsubtracttoHashSet() 把另一邊轉成 Set 來加速查詢。plusminus 不一樣,它們操作 List、不會去除重複,行為跟集合運算有明顯差異

最容易混在一起的是 subtract 和集合版的 minus,兩個都是「扣掉另一邊有的元素」,實作也都是把另一邊轉成 HashSet 再逐一比對,差別只在裝結果的容器:subtract 回傳 Set,重複的元素只留一份;minus 回傳 List,重複的元素原封不動留著。listOf(1, 2, 2, 3).subtract(listOf(3)) 得到 [1, 2]listOf(1, 2, 2, 3).minus(listOf(3)) 得到 [1, 2, 2]

Collection 篇到這裡結束。下一篇開始 Sequence 篇,從 Eager 到 Lazy,看看為什麼鏈式操作需要延遲執行

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 25:distinct / distinctBy — 去除重複
下一篇
Kotlin Lambda 從零開始 Day 27:從 Eager 到 Lazy — 為什麼需要 Sequence?
系列文
Kotlin Lambda 從零開始35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言